<?php

/**
 * tmhOAuth
 *
 * An OAuth 1.0A library written in PHP.
 * The library supports file uploading using multipart/form as well as general
 * REST requests. OAuth authentication is sent using the an Authorization Header.
 *
 * @author themattharris
 * @version 0.7.4
 *
 * 19 February 2013
 */
class tmhOAuth
{

    const VERSION = '0.7.4';

    var $response = array();

    /**
     * Creates a new tmhOAuth object
     *
     * @param string $config,
     *            the configuration to use for this request
     * @return void
     */
    public function __construct($config = array())
    {
        $this->params = array();
        $this->headers = array();
        $this->auto_fixed_time = false;
        $this->buffer = null;
        
        // default configuration options
        $this->config = array_merge(array(
            // leave 'user_agent' blank for default, otherwise set this to
            // something that clearly identifies your app
            'user_agent' => '',
            // default timezone for requests
            'timezone' => 'UTC',
            
            'use_ssl' => true,
            'host' => 'api.twitter.com',
            
            'consumer_key' => '',
            'consumer_secret' => '',
            'user_token' => '',
            'user_secret' => '',
            'force_nonce' => false,
            'nonce' => false, // used for checking signatures. leave as false for auto
            'force_timestamp' => false,
            'timestamp' => false, // used for checking signatures. leave as false for auto
                                                   
            // oauth signing variables that are not dynamic
            'oauth_version' => '1.0',
            'oauth_signature_method' => 'HMAC-SHA1',
            
            // you probably don't want to change any of these curl values
            'curl_connecttimeout' => 30,
            'curl_timeout' => 10,
            
            // for security this should always be set to 2.
            'curl_ssl_verifyhost' => 2,
            // for security this should always be set to true.
            'curl_ssl_verifypeer' => true,
            
            // you can get the latest cacert.pem from here http://curl.haxx.se/ca/cacert.pem
            'curl_cainfo' => dirname(__FILE__) . DIRECTORY_SEPARATOR . 'cacert.pem',
            'curl_capath' => dirname(__FILE__),
            
            'curl_followlocation' => false, // whether to follow redirects or not
                                                   
            // support for proxy servers
            'curl_proxy' => false, // really you don't want to use this if you are using streaming
            'curl_proxyuserpwd' => false, // format username:password for proxy, if required
            'curl_encoding' => '', // leave blank for all supported formats, else use gzip, deflate, identity
                                                
            // streaming API
            'is_streaming' => false,
            'streaming_eol' => "\r\n",
            'streaming_metrics_interval' => 60,
            
            // header or querystring. You should always use header!
            // this is just to help me debug other developers implementations
            'as_header' => true,
            'debug' => false
        ), $config);
        $this->set_user_agent();
        date_default_timezone_set($this->config['timezone']);
    }

    /**
     * Sets the useragent for PHP to use
     * If '$this->config['user_agent']' already has a value it is used instead of one
     * being generated.
     *
     * @return void value is stored to the config array class variable
     */
    private function set_user_agent()
    {
        if (! empty($this->config['user_agent']))
            return;
        
        if ($this->config['curl_ssl_verifyhost'] && $this->config['curl_ssl_verifypeer']) {
            $ssl = '+SSL';
        } else {
            $ssl = '-SSL';
        }
        
        $ua = 'tmhOAuth ' . self::VERSION . $ssl . ' - //github.com/themattharris/tmhOAuth';
        $this->config['user_agent'] = $ua;
    }

    /**
     * Generates a random OAuth nonce.
     * If 'force_nonce' is true a nonce is not generated and the value in the configuration will be retained.
     *
     * @param string $length
     *            how many characters the nonce should be before MD5 hashing. default 12
     * @param string $include_time
     *            whether to include time at the beginning of the nonce. default true
     * @return void value is stored to the config array class variable
     */
    private function create_nonce($length = 12, $include_time = true)
    {
        if ($this->config['force_nonce'] == false) {
            $sequence = array_merge(range(0, 9), range('A', 'Z'), range('a', 'z'));
            $length = $length > count($sequence) ? count($sequence) : $length;
            shuffle($sequence);
            
            $prefix = $include_time ? microtime() : '';
            $this->config['nonce'] = md5(substr($prefix . implode('', $sequence), 0, $length));
        }
    }

    /**
     * Generates a timestamp.
     * If 'force_timestamp' is true a nonce is not generated and the value in the configuration will be retained.
     *
     * @return void value is stored to the config array class variable
     */
    private function create_timestamp()
    {
        $this->config['timestamp'] = ($this->config['force_timestamp'] == false ? time() : $this->config['timestamp']);
    }

    /**
     * Encodes the string or array passed in a way compatible with OAuth.
     * If an array is passed each array value will will be encoded.
     *
     * @param mixed $data
     *            the scalar or array to encode
     * @return $data encoded in a way compatible with OAuth
     */
    private function safe_encode($data)
    {
        if (is_array($data)) {
            return array_map(array(
                $this,
                'safe_encode'
            ), $data);
        } else 
            if (is_scalar($data)) {
                return str_ireplace(array(
                    '+',
                    '%7E'
                ), array(
                    ' ',
                    '~'
                ), rawurlencode($data));
            } else {
                return '';
            }
    }

    /**
     * Decodes the string or array from it's URL encoded form
     * If an array is passed each array value will will be decoded.
     *
     * @param mixed $data
     *            the scalar or array to decode
     * @return string $data decoded from the URL encoded form
     */
    private function safe_decode($data)
    {
        if (is_array($data)) {
            return array_map(array(
                $this,
                'safe_decode'
            ), $data);
        } else 
            if (is_scalar($data)) {
                return rawurldecode($data);
            } else {
                return '';
            }
    }

    /**
     * Returns an array of the standard OAuth parameters.
     *
     * @return array all required OAuth parameters, safely encoded
     */
    private function get_defaults()
    {
        $defaults = array(
            'oauth_version' => $this->config['oauth_version'],
            'oauth_nonce' => $this->config['nonce'],
            'oauth_timestamp' => $this->config['timestamp'],
            'oauth_consumer_key' => $this->config['consumer_key'],
            'oauth_signature_method' => $this->config['oauth_signature_method']
        );
        
        // include the user token if it exists
        if ($this->config['user_token'])
            $defaults['oauth_token'] = $this->config['user_token'];
            
            // safely encode
        foreach ($defaults as $k => $v) {
            $_defaults[$this->safe_encode($k)] = $this->safe_encode($v);
        }
        
        return $_defaults;
    }

    /**
     * Extracts and decodes OAuth parameters from the passed string
     *
     * @param string $body
     *            the response body from an OAuth flow method
     * @return array the response body safely decoded to an array of key => values
     */
    public function extract_params($body)
    {
        $kvs = explode('&', $body);
        $decoded = array();
        foreach ($kvs as $kv) {
            $kv = explode('=', $kv, 2);
            $kv[0] = $this->safe_decode($kv[0]);
            $kv[1] = $this->safe_decode($kv[1]);
            $decoded[$kv[0]] = $kv[1];
        }
        return $decoded;
    }

    /**
     * Prepares the HTTP method for use in the base string by converting it to
     * uppercase.
     *
     * @param string $method
     *            an HTTP method such as GET or POST
     * @return void value is stored to the class variable 'method'
     */
    private function prepare_method($method)
    {
        $this->method = strtoupper($method);
    }

    /**
     * Prepares the URL for use in the base string by ripping it apart and
     * reconstructing it.
     *
     * Ref: 3.4.1.2
     *
     * @param string $url
     *            the request URL
     * @return void value is stored to the class variable 'url'
     */
    private function prepare_url($url)
    {
        $parts = parse_url($url);
        
        $port = isset($parts['port']) ? $parts['port'] : false;
        $scheme = $parts['scheme'];
        $host = $parts['host'];
        $path = isset($parts['path']) ? $parts['path'] : false;
        
        $port or $port = ($scheme == 'https') ? '443' : '80';
        
        if (($scheme == 'https' && $port != '443') || ($scheme == 'http' && $port != '80')) {
            $host = "$host:$port";
        }
        
        // the scheme and host MUST be lowercase
        $this->url = strtolower("$scheme://$host");
        // but not the path
        $this->url .= $path;
    }

    /**
     * Prepares all parameters for the base string and request.
     * Multipart parameters are ignored as they are not defined in the specification,
     * all other types of parameter are encoded for compatibility with OAuth.
     *
     * @param array $params
     *            the parameters for the request
     * @return void prepared values are stored in the class variable 'signing_params'
     */
    private function prepare_params($params)
    {
        // do not encode multipart parameters, leave them alone
        if ($this->config['multipart']) {
            $this->request_params = $params;
            $params = array();
        }
        
        // signing parameters are request parameters + OAuth default parameters
        $this->signing_params = array_merge($this->get_defaults(), (array) $params);
        
        // Remove oauth_signature if present
        // Ref: Spec: 9.1.1 ("The oauth_signature parameter MUST be excluded.")
        if (isset($this->signing_params['oauth_signature'])) {
            unset($this->signing_params['oauth_signature']);
        }
        
        // Parameters are sorted by name, using lexicographical byte value ordering.
        // Ref: Spec: 9.1.1 (1)
        uksort($this->signing_params, 'strcmp');
        
        // encode. Also sort the signed parameters from the POST parameters
        foreach ($this->signing_params as $k => $v) {
            $k = $this->safe_encode($k);
            
            if (is_array($v))
                $v = implode(',', $v);
            
            $v = $this->safe_encode($v);
            $_signing_params[$k] = $v;
            $kv[] = "{$k}={$v}";
        }
        
        // auth params = the default oauth params which are present in our collection of signing params
        $this->auth_params = array_intersect_key($this->get_defaults(), $_signing_params);
        if (isset($_signing_params['oauth_callback'])) {
            $this->auth_params['oauth_callback'] = $_signing_params['oauth_callback'];
            unset($_signing_params['oauth_callback']);
        }
        
        if (isset($_signing_params['oauth_verifier'])) {
            $this->auth_params['oauth_verifier'] = $_signing_params['oauth_verifier'];
            unset($_signing_params['oauth_verifier']);
        }
        
        // request_params is already set if we're doing multipart, if not we need to set them now
        if (! $this->config['multipart'])
            $this->request_params = array_diff_key($_signing_params, $this->get_defaults());
            
            // create the parameter part of the base string
        $this->signing_params = implode('&', $kv);
    }

    /**
     * Prepares the OAuth signing key
     *
     * @return void prepared signing key is stored in the class variable 'signing_key'
     */
    private function prepare_signing_key()
    {
        $this->signing_key = $this->safe_encode($this->config['consumer_secret']) . '&' . $this->safe_encode($this->config['user_secret']);
    }

    /**
     * Prepare the base string.
     * Ref: Spec: 9.1.3 ("Concatenate Request Elements")
     *
     * @return void prepared base string is stored in the class variable 'base_string'
     */
    private function prepare_base_string()
    {
        $url = $this->url;
        
        // if the host header is set we need to rewrite the basestring to use
        // that, instead of the request host. otherwise the signature won't match
        // on the server side
        if (! empty($this->custom_headers['Host'])) {
            $url = str_ireplace($this->config['host'], $this->custom_headers['Host'], $url);
        }
        
        $base = array(
            $this->method,
            $url,
            $this->signing_params
        );
        $this->base_string = implode('&', $this->safe_encode($base));
    }

    /**
     * Prepares the Authorization header
     *
     * @return void prepared authorization header is stored in the class variable headers['Authorization']
     */
    private function prepare_auth_header()
    {
        unset($this->headers['Authorization']);
        
        uksort($this->auth_params, 'strcmp');
        if (! $this->config['as_header']) :
            $this->request_params = array_merge($this->request_params, $this->auth_params);
            return;
        
    endif;
        
        foreach ($this->auth_params as $k => $v) {
            $kv[] = "{$k}=\"{$v}\"";
        }
        $this->auth_header = 'OAuth ' . implode(', ', $kv);
        $this->headers['Authorization'] = $this->auth_header;
    }

    /**
     * Signs the request and adds the OAuth signature.
     * This runs all the request
     * parameter preparation methods.
     *
     * @param string $method
     *            the HTTP method being used. e.g. POST, GET, HEAD etc
     * @param string $url
     *            the request URL without query string parameters
     * @param array $params
     *            the request parameters as an array of key=value pairs
     * @param string $useauth
     *            whether to use authentication when making the request.
     * @return void
     */
    private function sign($method, $url, $params, $useauth)
    {
        $this->prepare_method($method);
        $this->prepare_url($url);
        $this->prepare_params($params);
        
        // we don't sign anything is we're not using auth
        if ($useauth) {
            $this->prepare_base_string();
            $this->prepare_signing_key();
            
            $this->auth_params['oauth_signature'] = $this->safe_encode(base64_encode(hash_hmac('sha1', $this->base_string, $this->signing_key, true)));
            
            $this->prepare_auth_header();
        }
    }

    /**
     * Make an HTTP request using this library.
     * This method doesn't return anything.
     * Instead the response should be inspected directly.
     *
     * @param string $method
     *            the HTTP method being used. e.g. POST, GET, HEAD etc
     * @param string $url
     *            the request URL without query string parameters
     * @param array $params
     *            the request parameters as an array of key=value pairs. Default empty array
     * @param string $useauth
     *            whether to use authentication when making the request. Default true
     * @param string $multipart
     *            whether this request contains multipart data. Default false
     * @param array $headers
     *            any custom headers to send with the request. Default empty array
     * @return int the http response code for the request. 0 is returned if a connection could not be made
     */
    public function request($method, $url, $params = array(), $useauth = true, $multipart = false, $headers = array())
    {
        // reset the request headers (we don't want to reuse them)
        $this->headers = array();
        $this->custom_headers = $headers;
        
        $this->config['multipart'] = $multipart;
        
        $this->create_nonce();
        $this->create_timestamp();
        
        $this->sign($method, $url, $params, $useauth);
        
        if (! empty($this->custom_headers))
            $this->headers = array_merge((array) $this->headers, (array) $this->custom_headers);
        
        return $this->curlit();
    }

    /**
     * Make a long poll HTTP request using this library.
     * This method is
     * different to the other request methods as it isn't supposed to disconnect
     *
     * Using this method expects a callback which will receive the streaming
     * responses.
     *
     * @param string $method
     *            the HTTP method being used. e.g. POST, GET, HEAD etc
     * @param string $url
     *            the request URL without query string parameters
     * @param array $params
     *            the request parameters as an array of key=value pairs
     * @param string $callback
     *            the callback function to stream the buffer to.
     * @return void
     */
    public function streaming_request($method, $url, $params = array(), $callback = '')
    {
        if (! empty($callback)) {
            if (! is_callable($callback)) {
                return false;
            }
            $this->config['streaming_callback'] = $callback;
        }
        $this->metrics['start'] = time();
        $this->metrics['interval_start'] = $this->metrics['start'];
        $this->metrics['tweets'] = 0;
        $this->metrics['last_tweets'] = 0;
        $this->metrics['bytes'] = 0;
        $this->metrics['last_bytes'] = 0;
        $this->config['is_streaming'] = true;
        $this->request($method, $url, $params);
    }

    /**
     * Handles the updating of the current Streaming API metrics.
     *
     * @return array the metrics for the streaming api connection
     */
    private function update_metrics()
    {
        $now = time();
        if (($this->metrics['interval_start'] + $this->config['streaming_metrics_interval']) > $now)
            return false;
        
        $this->metrics['tps'] = round(($this->metrics['tweets'] - $this->metrics['last_tweets']) / $this->config['streaming_metrics_interval'], 2);
        $this->metrics['bps'] = round(($this->metrics['bytes'] - $this->metrics['last_bytes']) / $this->config['streaming_metrics_interval'], 2);
        
        $this->metrics['last_bytes'] = $this->metrics['bytes'];
        $this->metrics['last_tweets'] = $this->metrics['tweets'];
        $this->metrics['interval_start'] = $now;
        return $this->metrics;
    }

    /**
     * Utility function to create the request URL in the requested format
     *
     * @param string $request
     *            the API method without extension
     * @param string $format
     *            the format of the response. Default json. Set to an empty string to exclude the format
     * @return string the concatenation of the host, API version, API method and format
     */
    public function url($request, $format = 'json')
    {
        $format = strlen($format) > 0 ? ".$format" : '';
        $proto = $this->config['use_ssl'] ? 'https:/' : 'http:/';
        
        // backwards compatibility with v0.1
        if (isset($this->config['v']))
            $this->config['host'] = $this->config['host'] . '/' . $this->config['v'];
        
        $request = ltrim($request, '/');
        
        $pos = strlen($request) - strlen($format);
        if (substr($request, $pos) === $format)
            $request = substr_replace($request, '', $pos);
        
        return implode('/', array(
            $proto,
            $this->config['host'],
            $request . $format
        ));
    }

    /**
     * Public access to the private safe decode/encode methods
     *
     * @param string $text
     *            the text to transform
     * @param string $mode
     *            the transformation mode. either encode or decode
     * @return string $text transformed by the given $mode
     */
    public function transformText($text, $mode = 'encode')
    {
        return $this->{"safe_$mode"}($text);
    }

    /**
     * Utility function to parse the returned curl headers and store them in the
     * class array variable.
     *
     * @param object $ch
     *            curl handle
     * @param string $header
     *            the response headers
     * @return string the length of the header
     */
    private function curlHeader($ch, $header)
    {
        $this->response['raw'] .= $header;
        
        list ($key, $value) = array_pad(explode(':', $header, 2), 2, null);
        
        $key = trim($key);
        $value = trim($value);
        
        if (! isset($this->response['headers'][$key])) {
            $this->response['headers'][$key] = $value;
        } else {
            if (! is_array($this->response['headers'][$key])) {
                $this->response['headers'][$key] = array(
                    $this->response['headers'][$key]
                );
            }
            $this->response['headers'][$key][] = $value;
        }
        
        return strlen($header);
    }

    /**
     * Utility function to parse the returned curl buffer and store them until
     * an EOL is found.
     * The buffer for curl is an undefined size so we need
     * to collect the content until an EOL is found.
     *
     * This function calls the previously defined streaming callback method.
     *
     * @param object $ch
     *            curl handle
     * @param string $data
     *            the current curl buffer
     * @return int the length of the data string processed in this function
     */
    private function curlWrite($ch, $data)
    {
        $l = strlen($data);
        if (strpos($data, $this->config['streaming_eol']) === false) {
            $this->buffer .= $data;
            return $l;
        }
        
        $buffered = explode($this->config['streaming_eol'], $data);
        $content = $this->buffer . $buffered[0];
        
        $this->metrics['tweets'] ++;
        $this->metrics['bytes'] += strlen($content);
        
        if (! is_callable($this->config['streaming_callback']))
            return 0;
        
        $metrics = $this->update_metrics();
        $stop = call_user_func($this->config['streaming_callback'], $content, strlen($content), $metrics);
        $this->buffer = $buffered[1];
        if ($stop)
            return 0;
        
        return $l;
    }

    /**
     * Makes a curl request.
     * Takes no parameters as all should have been prepared
     * by the request method
     *
     * the response data is stored in the class variable 'response'
     *
     * @return int the http response code for the request. 0 is returned if a connection could not be made
     */
    private function curlit()
    {
        $this->response['raw'] = '';
        
        // method handling
        switch ($this->method) {
            case 'POST':
                break;
            default:
                // GET, DELETE request so convert the parameters to a querystring
                if (! empty($this->request_params)) {
                    foreach ($this->request_params as $k => $v) {
                        // Multipart params haven't been encoded yet.
                        // Not sure why you would do a multipart GET but anyway, here's the support for it
                        if ($this->config['multipart']) {
                            $params[] = $this->safe_encode($k) . '=' . $this->safe_encode($v);
                        } else {
                            $params[] = $k . '=' . $v;
                        }
                    }
                    $qs = implode('&', $params);
                    $this->url = strlen($qs) > 0 ? $this->url . '?' . $qs : $this->url;
                    $this->request_params = array();
                }
                break;
        }
        
        // configure curl
        $c = curl_init();
        curl_setopt_array($c, array(
            CURLOPT_USERAGENT => $this->config['user_agent'],
            CURLOPT_CONNECTTIMEOUT => $this->config['curl_connecttimeout'],
            CURLOPT_TIMEOUT => $this->config['curl_timeout'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_SSL_VERIFYPEER => $this->config['curl_ssl_verifypeer'],
            CURLOPT_SSL_VERIFYHOST => $this->config['curl_ssl_verifyhost'],
            
            CURLOPT_FOLLOWLOCATION => $this->config['curl_followlocation'],
            CURLOPT_PROXY => $this->config['curl_proxy'],
            CURLOPT_ENCODING => $this->config['curl_encoding'],
            CURLOPT_URL => $this->url,
            // process the headers
            CURLOPT_HEADERFUNCTION => array(
                $this,
                'curlHeader'
            ),
            CURLOPT_HEADER => false,
            CURLINFO_HEADER_OUT => true
        ));
        
        if ($this->config['curl_cainfo'] !== false)
            curl_setopt($c, CURLOPT_CAINFO, $this->config['curl_cainfo']);
        
        if ($this->config['curl_capath'] !== false)
            curl_setopt($c, CURLOPT_CAPATH, $this->config['curl_capath']);
        
        if ($this->config['curl_proxyuserpwd'] !== false)
            curl_setopt($c, CURLOPT_PROXYUSERPWD, $this->config['curl_proxyuserpwd']);
        
        if ($this->config['is_streaming']) {
            // process the body
            $this->response['content-length'] = 0;
            curl_setopt($c, CURLOPT_TIMEOUT, 0);
            curl_setopt($c, CURLOPT_WRITEFUNCTION, array(
                $this,
                'curlWrite'
            ));
        }
        
        switch ($this->method) {
            case 'GET':
                break;
            case 'POST':
                curl_setopt($c, CURLOPT_POST, true);
                curl_setopt($c, CURLOPT_POSTFIELDS, $this->request_params);
                break;
            default:
                curl_setopt($c, CURLOPT_CUSTOMREQUEST, $this->method);
        }
        
        if (! empty($this->request_params)) {
            // if not doing multipart we need to implode the parameters
            if (! $this->config['multipart']) {
                foreach ($this->request_params as $k => $v) {
                    $ps[] = "{$k}={$v}";
                }
                $this->request_params = implode('&', $ps);
            }
            curl_setopt($c, CURLOPT_POSTFIELDS, $this->request_params);
        }
        
        if (! empty($this->headers)) {
            foreach ($this->headers as $k => $v) {
                $headers[] = trim($k . ': ' . $v);
            }
            curl_setopt($c, CURLOPT_HTTPHEADER, $headers);
        }
        
        if (isset($this->config['prevent_request']) && (true == $this->config['prevent_request']))
            return 0;
            
            // do it!
        $response = curl_exec($c);
        $code = curl_getinfo($c, CURLINFO_HTTP_CODE);
        $info = curl_getinfo($c);
        $error = curl_error($c);
        $errno = curl_errno($c);
        curl_close($c);
        
        // store the response
        $this->response['code'] = $code;
        $this->response['response'] = $response;
        $this->response['info'] = $info;
        $this->response['error'] = $error;
        $this->response['errno'] = $errno;
        
        if (! isset($this->response['raw'])) {
            $this->response['raw'] = '';
        }
        $this->response['raw'] .= $response;
        
        return $code;
    }
}